---
sidebar_position: 13
---

# Шаг 11. Loki и Promtail

[**Grafana Loki**](https://grafana.com/oss/loki/) — это система для сбора, хранения и анализа логов, которая использует методы индексации на основе меток и предоставляет возможности запросов и визуализации логов облачных серверов через веб-интерфейс Grafana.

Loki был спроектирован с целью упрощения реализации в соответствии со следующими [принципами](https://habr.com/ru/companies/otus/articles/487118/):

- быть простым для старта;
- потреблять мало ресурсов;
- работать самостоятельно без какого-либо специального обслуживания;
- служить дополнением к Prometheus для помощи в расследовании багов.

Однако эта простота достигается за счет некоторых компромиссов. Один из них — не индексировать контент. Поэтому поиск по тексту не очень эффективен или богат и не позволяет вести статистику по содержимому текста. Но поскольку Loki хочет быть эквивалентом grep и дополнением к Prometheus, то это не является недостатком.

Согласно [официальной документации](https://grafana.com/docs/loki/latest/send-data/), у Loki есть разные инструменты для сборки журналов. Один из инструментов — это [**Promtail**](https://grafana.com/docs/loki/latest/send-data/promtail/). Самый простой способ отправлять журналы в Loki из обычных текстовых файлов (например, из файлов, которые регистрируются в `/var/log/*.log`). Promtail это делает хорошо! Этим мы и воспользуемся.

Реализуем следующую схему:

- Приложение на Python будет писать логи в директорию `/log` в файлы `*.log`. Эта директорию будет примонтированным томом Docker `app-log`.
- Поднимем контейнер с Promtail, к которому примонтируем томом Docker `app-log`. Он Promtail будет [собирать журналы](https://grafana.com/docs/grafana-cloud/send-data/logs/collect-logs-with-promtail/) из примонтированной директории и отправлять в Loki.
- Loki также будет поднят в отдельном контейнере.
- В завершении этого, мы укажем источники `datasources` у Grafana, чтобы мы могли работать с данными из Jaeger, Prometheus и Loki.

Начнем с того, что уточним структуру нашей директории:

```bash
observability
├── app
│   ├── .gitignore
│   ├── log
│   │   └── .gitkeep
│   ├── app.py
│   ├── Dockerfile
│   └── requirements.txt
├── grafana
│   └── grafana.yml
├── prometheus
│   └── prometheus.yml
├── promtail
│   └── promtail.yaml
├── .gitignore
└── docker-compose.yaml
```

Приступим к адаптации нашего приложения к извлечению журнала.

`app/app.py` будет выглядеть следующим образом:

```python
import os
import logging
from logging.config import dictConfig

from typing import Iterable
from prometheus_client import generate_latest
from flask import Flask
from random import randint


from opentelemetry import trace, metrics
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.metrics import Observation, CallbackOptions
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.exporter.prometheus import PrometheusMetricReader
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.logging import LoggingInstrumentor
from opentelemetry.sdk.resources import SERVICE_NAME, Resource


app = Flask(__name__)


def do_roll():
    with tracer.start_as_current_span("do_roll"):
        res = randint(1, 7)
        current_span = trace.get_current_span()
        current_span.set_attribute("roll.value", res)
        current_span.add_event("This is a span event")
        return res


def do_important_job():
    with tracer.start_as_current_span("do_important_job"):
        randint(1, 10000)


@app.route("/rolldice")
def roll_dice():
    request_counter.add(1)
    result = do_roll()
    do_important_job()
    if (result < 0 or result > 6):
        logging.getLogger().error("An incorrect number was received on the dice!")
        return 'something went wrong!', 500
    return str(result)


@app.route('/metrics')
def get_metrics():
    return generate_latest()


def cpu_time_callback(options: CallbackOptions) -> Iterable[Observation]:
    observations = []
    with open("/proc/stat") as procstat:
        procstat.readline()  # skip the first line
        for line in procstat:
            if not line.startswith("cpu"):
                break
            cpu, *states = line.split()
            observations.append(Observation(
                int(states[0]) // 100, {"cpu": cpu, "state": "user"}))
            observations.append(Observation(
                int(states[1]) // 100, {"cpu": cpu, "state": "system"}))
    return observations


def init_traces(resource):
    tracer_provider = TracerProvider(resource=resource)
    processor = BatchSpanProcessor(OTLPSpanExporter(
        endpoint=os.environ.get('TRACE_ENDPOINT', "http://localhost:4317")))
    tracer_provider.add_span_processor(processor)
    trace.set_tracer_provider(tracer_provider)
    tracer = trace.get_tracer(__name__)
    return tracer


def init_metrics(resource):
    metric_reader = PrometheusMetricReader()
    meter_provider = MeterProvider(
        resource=resource, metric_readers=[metric_reader])
    metrics.set_meter_provider(meter_provider)

    meter = metrics.get_meter_provider().get_meter(__name__)
    request_counter = meter.create_counter(
        name="request_counter", description="Number of requests", unit="1")
    meter.create_observable_counter(
        "system.cpu.time",
        callbacks=[cpu_time_callback],
        unit="s",
        description="CPU time"
    )
    return request_counter


def init_logs():
    LoggingInstrumentor().instrument(set_logging_format=True)
    dictConfig({
        'version': 1,
        'formatters': {'default': {
            'format': '%(asctime)s %(levelname)s [%(name)s] [%(filename)s:%(lineno)d] [trace_id=%(otelTraceID)s span_id=%(otelSpanID)s resource.service.name=%(otelServiceName)s trace_sampled=%(otelTraceSampled)s] - %(message)s',
        }},
        # highlight-start
        'handlers': {
            'wsgi': {
                'class': 'logging.StreamHandler',
                'stream': 'ext://flask.logging.wsgi_errors_stream',
                'formatter': 'default'
            },
            "file": {
                "class": "logging.FileHandler",
                "filename": "log/flask.log",
                "formatter": "default",
            }},
        'root': {
            'level': 'INFO',
            'handlers': ['wsgi', 'file']
        }
        # highlight-end
    })


resource = Resource.create({SERVICE_NAME: os.environ.get(
    'APP_SERVICE_NAME', "my-python-service")})
tracer = init_traces(resource)
request_counter = init_metrics(resource)
init_logs()

FlaskInstrumentor().instrument_app(app)

if __name__ == "__main__":
    host = os.environ.get('APP_HOST_NAME', "0.0.0.0")
    port = int(os.environ.get('APP_PORT', 5000))
    app.run(host=host, port=port)

```

Здесь мы уточнили то, [куда необходимо собирать логи](https://betterstack.com/community/guides/logging/how-to-start-logging-with-flask/), а именно в файл `log/flask.log`.

Файл `app/log/.gitkeep` нам нужен для того, чтобы директория `log` была уже создана и могли записываться туда журналы при локальной отладке.

Нам также следует обновить `app/Dockerfile`:

```Dockerfile
FROM python:3.12-slim-bookworm
COPY ./requirements.txt /app/requirements.txt
WORKDIR /app
RUN pip install -r requirements.txt
# highlight-start
RUN mkdir -p /app/log
COPY app.py /app
# highlight-end
EXPOSE 5000
CMD ["python", "app.py" ]

```

Файл `promtail/promtail.yaml` описывает то, как мы будем осуществлять сборку журналов и куда будем отправлять их:

```yaml
server:
  http_listen_port: 9080
  grpc_listen_port: 0

positions:
  filename: /tmp/positions.yaml

clients:
  - url: http://iu5devops-loki:3100/loki/api/v1/push

scrape_configs:
- job_name: flask-log-scraper
  static_configs:
  - targets:
      - localhost
    labels:
      __path__: "/log/*.log"
      app: app

```

Файл `grafana/grafana.yml` описывает то, как источники данных у нас имеются:

```yaml
apiVersion: 1

datasources:
  - name: Loki
    type: loki
    uid: iu5devops-loki
    access: proxy
    url: http://iu5devops-loki:3100
    basicAuth: false
    isDefault: false
    version: 1
    editable: true
    jsonData:
      derivedFields:
        - datasourceUid: iu5devops-jaeger
          name: JaegerTraceID
          matcherRegex: trace_id=(\w+)
          url: '$${__value.raw}'
        - name: TraceID
          matcherRegex: trace_id=(\w+)
          url: 'http://localhost:16686/trace/$${__value.raw}'
  - name: Prometheus
    type: prometheus
    access: proxy
    uid: iu5devops-prometheus
    url: http://iu5devops-prometheus:9090
  - name: Jaeger
    type: jaeger
    uid: iu5devops-jaeger
    url: http://iu5devops-jaeger:16686
    access: proxy
    readOnly: false
    isDefault: false

```

Подробная информация о том, как этот файл описывать, содержится в официальной документации:

- [для Jaeger](https://grafana.com/docs/grafana/latest/datasources/jaeger/);
- [для Prometheus](https://grafana.com/docs/grafana/latest/datasources/prometheus/);
- [для Loki](https://grafana.com/docs/grafana/latest/datasources/loki/).

В завершении этого [доработаем нашу конфигурацию](https://blog.ruanbekker.com/blog/2022/11/18/logging-with-docker-promtail-and-grafana-loki/) в `docker-compose.yaml`:

```Docker
services:
  app:
    image: iu5devops/app
    build:
      context: ./app
      dockerfile: Dockerfile
    container_name: iu5devops-app
    networks:
      - iu5devops
    ports:
      - 8080:5000
    environment:
      - FLASK_DEBUG=1
      - APP_SERVICE_NAME=iu5devops-app
      - TRACE_ENDPOINT=http://iu5devops-jaeger:4317
      - OTEL_PYTHON_LOG_CORRELATION=true
      - OTEL_PYTHON_LOG_LEVEL=info
    # highlight-start
    volumes:
      - app-log:/app/log
    # highlight-end


  jaeger:
    image: jaegertracing/all-in-one
    container_name: iu5devops-jaeger
    networks:
      - iu5devops
    ports:
      - 16686:16686

  prometheus:
    image: prom/prometheus
    container_name: iu5devops-prometheus
    networks:
      - iu5devops
    ports:
      - 9090:9090
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
# highlight-start
      - prometheus-data:/prometheus

  grafana:
    image: grafana/grafana
    container_name: iu5devops-grafana
    networks:
      - iu5devops
    ports:
      - 4000:3000
    volumes:
      - grafana-storage:/var/lib/grafana
      - ./grafana/grafana.yml:/etc/grafana/provisioning/datasources/datasources.yaml

  loki:
    image: grafana/loki:latest
    container_name: iu5devops-loki
    networks:
      - iu5devops
    ports:
      - 3100:3100
    command: -config.file=/etc/loki/local-config.yaml

  promtail:
    image:  grafana/promtail:latest
    container_name: iu5devops-promtail
    depends_on:
      - loki
    networks:
      - iu5devops
    volumes:
      - app-log:/log
      - ./promtail/promtail.yaml:/etc/promtail/docker-config.yaml
    command: -config.file=/etc/promtail/docker-config.yaml

volumes:
  grafana-storage: 
    name: iu5devops-grafana-storage
  prometheus-data: 
    name: iu5devops-prometheus-data
  app-log: 
    name: iu5devops-app-log

networks:
  iu5devops: 
    name: iu5devops
# highlight-end

```

Здесь мы добавили конкретные имена томам, подключили том для журналирования к приложению, и описали как поднимать контейнеры с Promtail и Loki.

:::info
Как уже ранее упоминалось, что контейнер с Jaeger основывается на образе `jaegertracing/all-in-one` для демонстрации работы с MLT. Для хранения данных используются такие СУБД, как [ClickHouse](https://github.com/jaegertracing/jaeger-clickhouse) и [Cassandra](https://www.jaegertracing.io/docs/1.6/features/#multiple-storage-backends). Рассмотрение использования данных хранилищ выходит за рамки данного пособия.
:::

Чтобы полюбоваться всем великолепием, мы должны переподнять Docker compose:

```bash
docker compose down
docker compose build
docker compose up -d
```
